package daybook.io;

import daybook.Resources;
import daybook.exception.InvalidException;
import daybook.exception.NotFoundException;
import daybook.model.*;
import daybook.model.transaction.*;
import daybook.util.Utils;
import daybook.util.tree.Tree;
import org.json.JSONArray;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * Created by crc on 16-9-1.
 */
public final class FromJson {
    private static final Logger LOG = LoggerFactory.getLogger(FromJson.class);
    private FromJson() {}

    public static Transaction transaction(JSONObject transactionJson, int order, Book book) {
        final LocalDate date = LocalDate.parse(transactionJson.getString("date"));
        final String description = transactionJson.isNull("description") ? "" : transactionJson.getString("description");
        final boolean checked = transactionJson.getBoolean("checked");
        final List<TransactionEntry> transactionEntries = Utils.toStream(transactionJson.getJSONArray("entries"), JSONArray::getJSONObject)
                .map(json -> transactionEntry(json, book))
                .collect(Collectors.toList());

        return ImmutableTransaction.builder()
                .date(date).order(order)
                .description(description)
                .isChecked(checked)
                .entries(transactionEntries)
                .build();
    }

    public static TransactionEntry transactionEntry(JSONObject transactionEntryJson, Book book) {
        final Account account = book.getChartOfAccounts()
                .findByCode(transactionEntryJson.getString("account"))
                .orElseThrow(() -> NotFoundException.of(Resources.ErrorMessages.ACCOUNT_NOT_FOUND_BY_CODE, transactionEntryJson.getString("account")));
        final Commodity commodity = account.getCommodity().orElse(book.getDefaultCurrency());
        final BigDecimal amount = transactionEntryJson.getBigDecimal("amount")
                .setScale(commodity.getFractionDigitCount(), RoundingMode.UNNECESSARY);
        final String description = transactionEntryJson.isNull("description") ? "" : transactionEntryJson.getString("description");
        final List<String> tags = transactionEntryJson.isNull("tags") ? Collections.emptyList()
                : Utils.toStream(transactionEntryJson.getJSONArray("tags"), JSONArray::getString).collect(Collectors.toList());

        final ImmutableTransactionEntry.Builder builder = ImmutableTransactionEntry.builder()
                .account(account)
                .amount(amount)
                .description(description)
                .tags(tags);

        if (!transactionEntryJson.isNull("items")) {
            final JSONArray array = transactionEntryJson.getJSONArray("items");
            for (int i = 0; i < array.length(); i++) {
                builder.addItems(item(array.getJSONObject(i), commodity, book.getDefaultCurrency()));
            }
        }

        return builder.build();
    }

    public static Item item(JSONObject json, Commodity commodity, Commodity defaultCommodity) {
        return ImmutableItem.of(
                json.getBigDecimal("count").setScale(commodity.getFractionDigitCount(), RoundingMode.UNNECESSARY),
                json.getBigDecimal("cost").setScale(defaultCommodity.getFractionDigitCount(), RoundingMode.UNNECESSARY));
    }

    public static Commodity commodity(JSONObject json) {
        return ImmutableCommodity.builder()
                .currency(json.getBoolean("currency"))
                .name(json.getString("name"))
                .fractionDigitCount(json.getInt("fractionDigitCount"))
                .description(json.optString("description", ""))
                .build();
    }

    public static Map<String, Commodity> commodities(JSONArray commodities) {
        if (commodities.length() == 0) {
            return Collections.emptyMap();
        }

        final Map<String, Commodity> map = new HashMap<>(commodities.length());
        for (int i = 0; i < commodities.length(); i++) {
            final Commodity commodity = commodity(commodities.getJSONObject(i));
            commodity.validate();
            try {
                Utils.putUniquely(map, commodity.getName(), commodity);
            } catch (IllegalArgumentException e) {
                throw InvalidException.of(e, Resources.ErrorMessages.DUPLICATED_COMMODITY_NAME, commodity.getName());
            }
        }
        return map;
    }

    private static final Commodity DEFAULT_CURRENCY = Commodity.CNY;
    public static Book book(JSONObject bookJson) {
        LOG.trace(">>> Book.of");
        final ImmutableBook.Builder builder = ImmutableBook.builder();

        final Map<String, Commodity> commodities = bookJson.isNull("commodities")
                ? Collections.singletonMap(DEFAULT_CURRENCY.getName(), DEFAULT_CURRENCY)
                : commodities(bookJson.getJSONArray("commodities"));
        if (LOG.isDebugEnabled()) {
            LOG.debug("=== of: find commodities: {}", SimpleExpression.commoditiesToString(commodities.values()));
        }
        builder.commodities(commodities);

        if (bookJson.isNull("defaultCurrency")) {
            LOG.debug("=== of: not find defaultCurrency, try use predefined default currency: {}", DEFAULT_CURRENCY.getName());
            final Commodity commodity = commodities.get(DEFAULT_CURRENCY.getName());
            if (commodity == null) {
                LOG.debug("=== of: not find commodity ({}) in commodities. use it as default currency and add to commodities", DEFAULT_CURRENCY.getName());
                commodities.put(DEFAULT_CURRENCY.getName(), DEFAULT_CURRENCY);
                builder.defaultCurrency(DEFAULT_CURRENCY);
            } else if (commodity.similar(DEFAULT_CURRENCY)) {
                LOG.debug("=== of: find commodity ({}) in commodities and it's the same as predefined default currency. use it as default currency", DEFAULT_CURRENCY.getName());
                builder.defaultCurrency(commodity);
            } else {
                LOG.debug("=== of: find commodity ({}) in commodities and it's not the same as predefined default currency. ERROR", DEFAULT_CURRENCY.getName());
                throw InvalidException.of(Resources.ErrorMessages.MISSING_DEFAULT_CURRENCY);
            }
        } else {
            final String currencyName = bookJson.getString("defaultCurrency");
            LOG.debug("=== of: find defaultCurrency name: {}", currencyName);
            final Commodity defaultCurrency = commodities.get(currencyName);
            if (defaultCurrency == null) {
                LOG.debug("=== of: not find defaultCurrency name ({}) in commodities", currencyName);
                throw InvalidException.of(Resources.ErrorMessages.COMMODITY_NOT_FOUND, currencyName);
            } else if (!defaultCurrency.isCurrency()) {
                LOG.debug("=== of: find commodity ({}), but it's not currency", currencyName);
                throw InvalidException.of(Resources.ErrorMessages.NOT_CURRENCY, currencyName);
            }
            LOG.debug("=== of: find defaultCurrency: {}", defaultCurrency);
            builder.defaultCurrency(defaultCurrency);
        }

        String accountNameSeparator = Book.DEFAULT_ACCOUNT_NAME_SEPARATOR;
        if (!bookJson.isNull("accountNameSeparator")) {
            accountNameSeparator = bookJson.getString("accountNameSeparator");
            LOG.debug("=== of: find accountNameSeparator: \"{}\"", accountNameSeparator);
            if (!Book.isValidAccountNameSeparator(accountNameSeparator)) {
                LOG.debug("=== of: accountNameSeparator \"{}\" is invalid", accountNameSeparator);
                throw InvalidException.of(Resources.ErrorMessages.INVALID_ACCOUNT_NAME_SEPARATOR, accountNameSeparator);
            }
            builder.accountNameSeparator(accountNameSeparator);
        }
        LOG.debug("=== of: account name separator: \"{}\"", accountNameSeparator);

        LOG.debug("=== of: parsing accounts");
        final Map<String, Account> codeToAccount = makeAccountMap(bookJson.getJSONArray("accounts"), commodities, accountNameSeparator);
        LOG.debug("=== of: parsed accounts");
        LOG.debug("=== of: building chart of accounts");
        final ChartOfAccounts chartOfAccounts = ChartOfAccounts.of(makeAccountTree(codeToAccount), accountNameSeparator);
        LOG.debug("=== of: builded chart of accounts");
        LOG.trace("<<< Book.of");
        return builder.chartOfAccounts(chartOfAccounts).build();
    }

    private static Map<String, Account> makeAccountMap(JSONArray accounts, Map<String, Commodity> nameToCommodity, String accountNameSeparator) {
        LOG.trace(">>> makeAccountMap");
        final Map<String, Account> codeToAccount = new HashMap<>(accounts.length() + 1);
        final Map<String, List<JSONObject>> parentToAccountJsons = new HashMap<>();
        codeToAccount.put(Account.ROOT.getCode(), Account.ROOT);

        for (int i = 0; i < accounts.length(); i++) {
            final JSONObject accountJson = accounts.getJSONObject(i);
            if (LOG.isDebugEnabled()) {
                LOG.debug("=== makeAccountMap: find account: {} ({})",
                        accountJson.optString("name"), accountJson.optString("code"));
            }
            if (accountJson.isNull("parent")) {
                // 顶层账户,没有父账户
                final  Account account = makeAccount(accountJson, null, nameToCommodity, accountNameSeparator);
                LOG.debug("=== makeAccountMap: top level account: {} ({})", account.getName(), account.getCode());
                addAccount(codeToAccount, account, nameToCommodity, parentToAccountJsons, accountNameSeparator);
            } else {
                // 子账户
                final String parentCode = accountJson.getString("parent");
                final Account parent = codeToAccount.get(parentCode);
                // 父账户已创建,在codeToAccount中
                if (parent != null) {
                    LOG.debug("=== makeAccountMap: find parent account");
                    final Account account = makeAccount(accountJson, parent.getType(), nameToCommodity, accountNameSeparator);
                    LOG.debug("=== makeAccountMap: sub account: {} ({})", account.getName(), account.getCode());
                    addAccount(codeToAccount, account, nameToCommodity, parentToAccountJsons, accountNameSeparator);
                } else {
                    LOG.debug("=== makeAccountMap: not find parent account. pending");
                    // 父账户尚未创建
                    Utils.addToMultiMap(parentToAccountJsons, parentCode, accountJson);
                }
            }
        }

        if (!Utils.minifyMultiMap(parentToAccountJsons).isEmpty()) {
            final String accountsNotFound = parentToAccountJsons.keySet().stream().collect(Collectors.joining(", "));
            LOG.debug("=== makeAccountMap: cannot find accounts: {}", accountsNotFound);
            throw NotFoundException.of(Resources.ErrorMessages.ACCOUNTS_NOT_FOUND, accountsNotFound);
        }

        LOG.trace("<<< makeAccountMap");
        return codeToAccount;
    }

    private static Tree<Account> makeAccountTree(Map<String, Account> codeToAccount) {
        final Map<Account, List<Account>> mapTree = new HashMap<>();

        for (Account account: codeToAccount.values()) {
            if (account != Account.ROOT) {
                final Account parent = codeToAccount.get(account.getParentCode());
                if (parent == null) {
                    throw NotFoundException.of(Resources.ErrorMessages.ACCOUNT_NOT_FOUND_BY_CODE,
                            account.getParentCode());
                }

                Utils.addToMultiMap(mapTree, parent, account);
            }
        }

        return Tree.buildTree(Account.ROOT, mapTree);
    }

    private static Account makeAccount(JSONObject accountJson, Account.Type type, Map<String, Commodity> nameToCommodity, String accountNameSeparator) {
        final String code = accountJson.getString("code");
        if (accountJson.isNull("parent")) {
            if (accountJson.isNull("type")) {
                throw InvalidException.of(Resources.ErrorMessages.MISSING_TYPE_FOR_TOP_LEVEL_ACCOUNT, code);
            }
        } else {
            if (!accountJson.isNull("type")) {
                throw InvalidException.of(Resources.ErrorMessages.HAVING_TYPE_FOR_SUB_ACCOUNT, code);
            }
        }

        final ImmutableAccount.Builder builder = ImmutableAccount.builder()
                .code(code)
                .name(accountJson.getString("name"));

        if (!accountJson.isNull("type")) {
            try {
                builder.type(Account.Type.valueOf(accountJson.getString("type").toUpperCase()));
            } catch (IllegalArgumentException e) {
                throw InvalidException.of(e, Resources.ErrorMessages.INVALID_ACCOUNT_TYPE, accountJson.getString("type"));
            }
        } else if (type != null) {
            builder.type(type);
        } else {
            throw new IllegalArgumentException("missing account type");
        }

        if (!accountJson.isNull("commodity")) {
            final Commodity commodity = nameToCommodity.get(accountJson.getString("commodity"));
            if (commodity == null) {
                throw NotFoundException.of(Resources.ErrorMessages.COMMODITY_NOT_FOUND,
                        accountJson.getString("commodity"));
            }
            builder.commodity(commodity);
        }

        if (!accountJson.isNull("aliases")) {
            builder.addAllAliases(Utils.toStream(accountJson.getJSONArray("aliases"), JSONArray::getString).collect(Collectors.toList()));
        }
        if (!accountJson.isNull("description")) {
            builder.description(accountJson.getString("description"));
        }
        if (!accountJson.isNull("attributes")) {
            builder.addAllAttributes(Utils.toStream(accountJson.getJSONArray("attributes"), JSONArray::getString).collect(Collectors.toList()));
        }
        if (!accountJson.isNull("parent")) {
            builder.parentCode(accountJson.getString("parent"));
        }

        final Account account = builder.build();
        return account;
    }

    private static void addAccount(Map<String, Account> codeToAccount, Account parent,
                                   Map<String, Commodity> nameToCommodity,
                                   Map<String, List<JSONObject>> parentToAccountJsons,
                                   String accountNameSeparator) {
        LOG.trace(">>> addAccount");
        LOG.debug("=== addAccount: account: {} ({})", parent.getName(), parent.getCode());
        try {
            Utils.putUniquely(codeToAccount, parent.getCode(), parent);
        } catch (IllegalArgumentException e) {
            throw InvalidException.of(e, Resources.ErrorMessages.DUPLICATED_ACCOUNT_CODE, parent.getCode());
        }

        final List<JSONObject> accountJsons = parentToAccountJsons.get(parent.getCode());
        if (accountJsons != null && !accountJsons.isEmpty()) {
            for (JSONObject accountJson: accountJsons) {
                final Account account = makeAccount(accountJson, parent.getType(), nameToCommodity, accountNameSeparator);
                LOG.debug("=== addAccount: add sub account: {} ({})", account.getName(), account.getCode());
                try {
                    Utils.putUniquely(codeToAccount, account.getCode(), account);
                } catch (IllegalArgumentException e) {
                    throw InvalidException.of(e, Resources.ErrorMessages.DUPLICATED_ACCOUNT_CODE, account.getCode());
                }
            }
            accountJsons.clear();
        }
        LOG.trace("<<< addAccount");
    }
}
